Banner image for this article, an unsplash image with coding theme

Jibé Barth

🇫🇷 Web developer

PestPHP on Symfony demo

Published May 13, 2020

PestPHP on Symfony demo

pest logo

Nuno ask me to test his new test framework called Pest on a symfony application.

I decided to test it on Symfony Demo and rewrite all included tests with Pest.

Installation

Just after cloning the Symfony demo repository, I followed this doc: https://pestphp.com/docs/installation/

I faced with two problems :

In composer.json, the php config is setted to version 7.2.9, so I got this error:

  [InvalidArgumentException]                                                                         
  Package pestphp/pest at version  has a PHP requirement incompatible with your PHP version (7.2.9)  

Fixed it by removing this config in composer.json

     "config": {
-        "platform": {
-            "php": "7.2.9"
-        },

Then, the minimum stability of the application is defined to "stable". As pest require nunomaduro/collision in v5.0 and there is not stable version of this package yet, I add to change the minimum stability.

composer config minimum-stability beta

Analyse

Launched phpunit on project:

$ vendor/bin/phpunit 
PHPUnit 9.1.4 by Sebastian Bergmann and contributors.

Testing 
...............................................                   47 / 47 (100%)

Time: 00:09.030, Memory: 64.50 MB

OK (47 tests, 112 assertions)

47 tests, 112 assertions, I have to do the same.

tests
├── bootstrap.php
├── Command
│   └── AddUserCommandTest.php
├── Controller
│   ├── Admin
│   │   └── BlogControllerTest.php
│   ├── BlogControllerTest.php
│   ├── DefaultControllerTest.php
│   └── UserControllerTest.php
├── Form
│   └── DataTransformer
│       └── TagArrayToStringTransformerTest.php
└── Utils
    └── ValidatorTest.php

Sweet. Command tests use KernelTestCase, Controller tests use WebTestCase, and pure unit tests in Form and Utils.

Let's begin...

Using Pest

Unit Tests

The first test I want to rewrite was ValidatorTest. It seems to be the simpliest :

    public function testValidateUsername(): void
    {
        $test = 'username';

        $this->assertSame($test, $this->validator->validateUsername($test));
    }

    // ...

I move all tests folder in an other, and let's initialize a new tests folder for Pest.

Then, I create a new ValidatorTest in Utils with this :

<?php

use App\Utils\Validator;

beforeEach(function () {
    $this->validator = new Validator();
});

/**
 * @covers Validator::validateUsername
 */
test('validate username', fn ($test) => assertSame($test, $this->validator->validateUsername($test)))
    ->with(['test']);

test('validate empty username', fn ($value) => $this->validator->validateUsername($value))
    ->with([null, ''])
    ->throws(\Exception::class, 'The username can not be empty.');

test('validate username invalid', fn ($value) => $this->validator->validateUsername($value))
    ->with(['INVALID', 'jibé'])
    ->throws(\Exception::class, 'The username must contain only lowercase latin characters and underscores.');

/**
 * @covers Validator::validatePassword
 */
test('validate password', fn ($test) => assertSame($test, $this->validator->validatePassword($test)))
    ->with(['password']);

test('validate empty password', fn ($value) => $this->validator->validatePassword($value))
    ->with([null, ''])
    ->throws(\Exception::class, 'The password can not be empty.');

test('validate invalid password', fn ($value) => $this->validator->validatePassword($value))
    ->with(['12345'])
    ->throws(\Exception::class, 'The password must be at least 6 characters long.');

/**
 * @covers Validator::validateEmail
 */
test('validate email', fn ($test) => assertSame($test, $this->validator->validateEmail($test)))
    ->with(['@', 'contact@example.net']);

test('validate empty email', fn ($value) => $this->validator->validateEmail($value))
    ->with([null, ''])
    ->throws(\Exception::class, 'The email can not be empty.');

test('validate invalid email', fn ($value) => $this->validator->validateEmail($value))
    ->with(['invalid', 'example.net'])
    ->throws(\Exception::class, 'The email should look like a real email.');

/**
 * @covers Validator::validateFullName
 */
test('validate fullname', fn ($test) => assertSame($test, $this->validator->validateFullName($test)))
    ->with(['Full Name']);

test('validate empty fullname', fn ($value) => $this->validator->validateFullName($value))
    ->with([null, ''])
    ->throws(\Exception::class, 'The full name can not be empty.');

Output is nice 👌 :

first output with pest

Comparing to original test, I gain ~ 40LOC. I added the @covers annotations, to aerate a little the code, but it's not required.

One thing strange is the ✓ validate username invalid with (' i n v a l i d'). I passed INVALID data, and it seems to be transformed. However, after a check, the correct value is passed, seems to be only when display results. Don't know if it's phpunit related or pest.

The second Test I migrate is the TagArrayToStringTransformerTest. It's interresting because it use Mock. Let's see how it can be implemented with Pest.

On the original test, the class had the following method :

    private function getMockedTransformer(array $findByReturnValues = []): TagArrayToStringTransformer
    {
        $tagRepository = $this->getMockBuilder(TagRepository::class)
            ->disableOriginalConstructor()
            ->getMock();
        $tagRepository->expects($this->any())
            ->method('findBy')
            ->willReturn($findByReturnValues);

        return new TagArrayToStringTransformer($tagRepository);
    }

I didn't find a way to register such a function with $this in Pest. However, I add a function in my test file, and adding a TestCase argument :

function getMockedTransformer(\PHPUnit\Framework\TestCase $test, array $findByReturnValues = []): TagArrayToStringTransformer
{
    $tagRepository = $test->getMockBuilder(TagRepository::class)
        ->disableOriginalConstructor()
        ->getMock();
    $tagRepository->expects($test->any())
        ->method('findBy')
        ->willReturn($findByReturnValues);

    return new TagArrayToStringTransformer($tagRepository);
}

Then, I can call it by adding $this in parameter :

it('create the right amount of tag', function () {
    $tags = getMockedTransformer($this)->reverseTransform('Hello, Demo, How');

    assertCount(3, $tags);
    assertSame('Hello', $tags[0]->getName());
});

Maybe this function should be added in a Trait, and use the uses feature of Pest.

Then the transform is pretty simple:

<?php

use App\Entity\Tag;
use App\Form\DataTransformer\TagArrayToStringTransformer;
use App\Repository\TagRepository;

it('create the right amount of tag', function () {
    $tags = getMockedTransformer($this)->reverseTransform('Hello, Demo, How');

    assertCount(3, $tags);
    assertSame('Hello', $tags[0]->getName());
});

it('create the right amount of tags with too many commas', function () {
    $transformer = getMockedTransformer($this);

    assertCount(3, $transformer->reverseTransform('Hello, Demo,, How'));
    assertCount(3, $transformer->reverseTransform('Hello, Demo, How,'));
});

it('trim names' , function () {
    $tags = getMockedTransformer($this)->reverseTransform('   Hello   ');
    assertSame('Hello', $tags[0]->getName());
});

test('duplicate names', function () {
    $tags = getMockedTransformer($this)->reverseTransform('Hello, Hello, Hello');

    assertCount(1, $tags);
});

it('uses already defined tags', function () {
    $persistedTags = [
        createTag('Hello'),
        createTag('World'),
    ];
    $tags = getMockedTransformer($this, $persistedTags)->reverseTransform('Hello, World, How, Are, You');

    assertCount(5, $tags);
    assertSame($persistedTags[0], $tags[0]);
    assertSame($persistedTags[1], $tags[1]);
});

test('transform', function () {
    $persistedTags = [
        createTag('Hello'),
        createTag('World'),
    ];
    $transformed = getMockedTransformer($this)->transform($persistedTags);

    assertSame('Hello,World', $transformed);
});

function getMockedTransformer(\PHPUnit\Framework\TestCase $test, array $findByReturnValues = []): TagArrayToStringTransformer
{
    $tagRepository = $test->getMockBuilder(TagRepository::class)
        ->disableOriginalConstructor()
        ->getMock();
    $tagRepository->expects($test->any())
        ->method('findBy')
        ->willReturn($findByReturnValues);

    return new TagArrayToStringTransformer($tagRepository);
}

function createTag(string $name): Tag
{
    $tag = new Tag();
    $tag->setName($name);

    return $tag;
}

Let's see how it handle our lovely KernelTestCase

Integration Tests

Following docs, we can define the base TestCase in a tests/Pest.php file by directory. So, for Command folder, I want it use KernelTestCase

// tests/Pest.php

<?php

use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;

uses(KernelTestCase::class)->in('Command');

For this one, I tried to create a Trait for the executeCommand :

// tests/Command/ExecuteAddUserCommandTrait.php
<?php

namespace App\Tests\Command;

use App\Command\AddUserCommand;
use Symfony\Bundle\FrameworkBundle\Console\Application;
use Symfony\Component\Console\Tester\CommandTester;

trait ExecuteAddUserCommandTrait
{
    private function executeCommand(array $arguments, array $inputs = []): void
    {
        self::bootKernel();

        // this uses a special testing container that allows you to fetch private services
        $command = self::$container->get(AddUserCommand::class);
        $command->setApplication(new Application(self::$kernel));

        $commandTester = new CommandTester($command);
        $commandTester->setInputs($inputs);
        $commandTester->execute($arguments);
    }
}

and an other for custom assertion assertUserCreated :

<?php

namespace App\Tests\Command;

use App\Repository\UserRepository;

trait UserCreationAssertion
{
    private $userData = [
        'username' => 'chuck_norris',
        'password' => 'foobar',
        'email' => 'chuck@norris.com',
        'full-name' => 'Chuck Norris',
    ];

    private function assertUserCreated(bool $isAdmin): void
    {
        $container = self::$container;

        /** @var \App\Entity\User $user */
        $user = $container->get(UserRepository::class)->findOneByEmail($this->userData['email']);
        $this->assertNotNull($user);

        $this->assertSame($this->userData['full-name'], $user->getFullName());
        $this->assertSame($this->userData['username'], $user->getUsername());
        $this->assertTrue($container->get('security.password_encoder')->isPasswordValid($user, $this->userData['password']));
        $this->assertSame($isAdmin ? ['ROLE_ADMIN'] : ['ROLE_USER'], $user->getRoles());
    }
}

Then, I add a use for theses trait in the AddUserCommandTest:

<?php

namespace App\Tests\Command;

uses(UserCreationAssertion::class, ExecuteAddUserCommandTrait::class);

Let's transform other tests..

The original test use a DataProvider, so I created a dataset for it :

dataset('isAdmin', static function () {
    yield [false];
    yield [true];
});

And there is the full test :

<?php

namespace App\Tests\Command;

uses(UserCreationAssertion::class, ExecuteAddUserCommandTrait::class);

dataset('isAdmin', static function () {
    yield [false];
    yield [true];
});

it('create user in non interactive mode', function (bool $isAdmin) {
    $input = $this->userData;
    if ($isAdmin) {
        $input['--admin'] = 1;
    }
    $this->executeCommand($input);
    $this->assertUserCreated($isAdmin);
})->with('isAdmin');

it('create user in interactive mode', function (bool $isAdmin) {
    $this->executeCommand(
        // these are the arguments (only 1 is passed, the rest are missing)
        $isAdmin ? ['--admin' => 1] : [],
        // these are the responses given to the questions asked by the command
        // to get the value of the missing required arguments
        array_values($this->userData)
    );

    $this->assertUserCreated($isAdmin);
})->with('isAdmin');

beforeEach(function () {
    exec('stty 2>&1', $output, $exitcode);
    $isSttySupported = 0 === $exitcode;

    if ('Windows' === PHP_OS_FAMILY || !$isSttySupported) {
        $this->markTestSkipped('`stty` is required to test this command.');
    }
});

Functional tests

I call functionnal tests the tests which call a controller via a Client. To do this, we use in Symfony the WebTestCase.

Adding it in tests/Pest.php for all our controllers tests.

// tests/Pest.php

// ...

uses(WebTestCase::class)->in('Controller');

For the DefaultControllerTest, it's pretty quick:

<?php

use App\Entity\Post;
use Symfony\Component\HttpFoundation\Response;

test('public urls', function (string $url) {
    $client = static::createClient();
    $client->request('GET', $url);

    $this->assertResponseIsSuccessful(sprintf('The %s public URL loads correctly.', $url));
})->with(static function (): ?\Generator {
    yield ['/'];
    yield ['/en/blog/'];
    yield ['/en/login'];
});

test('public blog posts', function () {
    $client = static::createClient();
    // the service container is always available via the test client
    $blogPost = $client->getContainer()->get('doctrine')->getRepository(Post::class)->find(1);
    $client->request('GET', sprintf('/en/blog/posts/%s', $blogPost->getSlug()));

    $this->assertResponseIsSuccessful();
});

test('secure urls', function ($url) {
    $client = static::createClient();
    $client->request('GET', $url);

    $this->assertResponseRedirects(
        'http://localhost/en/login',
        Response::HTTP_FOUND,
        sprintf('The %s secure URL redirects to the login form.', $url)
    );
})->with(static function (): ?\Generator {
    yield ['/en/admin/post/'];
    yield ['/en/admin/post/new'];
    yield ['/en/admin/post/1'];
    yield ['/en/admin/post/1/edit'];
});

For BlogControllerTest and UserControllerTest, same. Just copy paste content of tests into Pest Style :

// ...
it('can post new comment', function () {
    $client = static::createClient([], [
        'PHP_AUTH_USER' => 'john_user',
        'PHP_AUTH_PW' => 'kitten',
    ]);
    $client->followRedirects();

    // Find first blog post
    $crawler = $client->request('GET', '/en/blog/');
    $postLink = $crawler->filter('article.post > h2 a')->link();

    $client->click($postLink);
    $crawler = $client->submitForm('Publish comment', [
        'comment[content]' => 'Hi, Symfony!',
    ]);

    $newComment = $crawler->filter('.post-comment')->first()->filter('div > p')->text();

    $this->assertSame('Hi, Symfony!', $newComment);
});

For Admin/BlogControllerTest, there was an other private method in original class, I declared it as simple function here :

<?php

use App\Repository\PostRepository;
use Symfony\Component\HttpFoundation\Response;

it('deny access for regular user', function ($httpMethod, $url) {
    $client = static::createClient([], [
        'PHP_AUTH_USER' => 'john_user',
        'PHP_AUTH_PW' => 'kitten',
    ]);

    $client->request($httpMethod, $url);

    $this->assertResponseStatusCodeSame(Response::HTTP_FORBIDDEN);
})->with(static function(): \Generator {
    yield ['GET', '/en/admin/post/'];
    yield ['GET', '/en/admin/post/1'];
    yield ['GET', '/en/admin/post/1/edit'];
    yield ['POST', '/en/admin/post/1/delete'];
});


it('has admin backend home page', function () {
    $client = static::createClient([], [
        'PHP_AUTH_USER' => 'jane_admin',
        'PHP_AUTH_PW' => 'kitten',
    ]);
    $client->request('GET', '/en/admin/post/');

    $this->assertResponseIsSuccessful();
    $this->assertSelectorExists(
        'body#admin_post_index #main tbody tr',
        'The backend homepage displays all the available posts.'
    );
});

it('can create new post', function () {
    $postTitle = 'Blog Post Title '.mt_rand();
    $postSummary = generateRandomString(255);
    $postContent = generateRandomString(1024);

    $client = static::createClient([], [
        'PHP_AUTH_USER' => 'jane_admin',
        'PHP_AUTH_PW' => 'kitten',
    ]);
    $client->request('GET', '/en/admin/post/new');
    $client->submitForm('Create post', [
        'post[title]' => $postTitle,
        'post[summary]' => $postSummary,
        'post[content]' => $postContent,
    ]);

    $this->assertResponseRedirects('/en/admin/post/', Response::HTTP_FOUND);

    /** @var \App\Entity\Post $post */
    $post = self::$container->get(PostRepository::class)->findOneByTitle($postTitle);
    $this->assertNotNull($post);
    $this->assertSame($postSummary, $post->getSummary());
    $this->assertSame($postContent, $post->getContent());
});

it('can duplicate post', function () {
    $postTitle = 'Blog Post Title '.mt_rand();
    $postSummary = generateRandomString(255);
    $postContent = generateRandomString(1024);

    $client = static::createClient([], [
        'PHP_AUTH_USER' => 'jane_admin',
        'PHP_AUTH_PW' => 'kitten',
    ]);
    $crawler = $client->request('GET', '/en/admin/post/new');
    $form = $crawler->selectButton('Create post')->form([
        'post[title]' => $postTitle,
        'post[summary]' => $postSummary,
        'post[content]' => $postContent,
    ]);
    $client->submit($form);

    // post titles must be unique, so trying to create the same post twice should result in an error
    $client->submit($form);

    $this->assertSelectorTextSame('form .form-group.has-error label', 'Title');
    $this->assertSelectorTextContains('form .form-group.has-error .help-block', 'This title was already used in another blog post, but they must be unique.');
});

it('can show post in admin', function () {
    $client = static::createClient([], [
        'PHP_AUTH_USER' => 'jane_admin',
        'PHP_AUTH_PW' => 'kitten',
    ]);
    $client->request('GET', '/en/admin/post/1');

    $this->assertResponseIsSuccessful();
});

it('can edit post', function () {
    $newBlogPostTitle = 'Blog Post Title '.mt_rand();

    $client = static::createClient([], [
        'PHP_AUTH_USER' => 'jane_admin',
        'PHP_AUTH_PW' => 'kitten',
    ]);
    $client->request('GET', '/en/admin/post/1/edit');
    $client->submitForm('Save changes', [
        'post[title]' => $newBlogPostTitle,
    ]);

    $this->assertResponseRedirects('/en/admin/post/1/edit', Response::HTTP_FOUND);

    /** @var \App\Entity\Post $post */
    $post = self::$container->get(PostRepository::class)->find(1);
    $this->assertSame($newBlogPostTitle, $post->getTitle());
});

it('can delete post', function () {
    $client = static::createClient([], [
        'PHP_AUTH_USER' => 'jane_admin',
        'PHP_AUTH_PW' => 'kitten',
    ]);
    $crawler = $client->request('GET', '/en/admin/post/1');
    $client->submit($crawler->filter('#delete-form')->form());

    $this->assertResponseRedirects('/en/admin/post/', Response::HTTP_FOUND);

    $post = self::$container->get(PostRepository::class)->find(1);
    $this->assertNull($post);
});

function generateRandomString(int $length): string
{
    $chars = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ';

    return mb_substr(str_shuffle(str_repeat($chars, ceil($length / mb_strlen($chars)))), 1, $length);
}

Final thoughts

Basculating all tests from Symfony Demo is pretty simple and everything works 🎉 At the end, I have 54 tests, so seven more of the original test suite. It's because the ->with() function of Pest is really cool, and I added more value in it while rewriting tests.

I also liked ->throw() function, to catch exception and messages.

But it's difficult, for me, to not having real context on $this. Using PHPStorm, I don't have any autocomplete on $this.

Also, there is no "live" progress on tests. Pest display results file by file, not "test" by "test". When using the Client, it's a bit slow to get results.

pest demo

Pest is still young, but it's promising !

Congrat Nuno for this awesome work and thanks to let me try it 👍 !